Skip to content

[WEB-8332] fix(security): block workspace-member mass-assignment - #9460

Open
mguptahub wants to merge 6 commits into
previewfrom
web-8332/workspace-member-mass-assignment
Open

mguptahub wants to merge 6 commits into
previewfrom
web-8332/workspace-member-mass-assignment

Conversation

@mguptahub

@mguptahub mguptahub commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

CRITICAL — fixes a cross-tenant workspace takeover (WEB-8332). Confirmed-vulnerable on origin/preview.

WorkSpaceMemberViewSet.partial_update passed request.data straight into WorkSpaceMemberSerializer (fields = "__all__", only nested member read-only). workspace, role, and is_active were therefore mass-assignable. An admin of an attacker-owned workspace could PATCH a member row setting workspace=victim-workspace UUID and role=20 → relocate a controlled account into the victim workspace as an admin with no invitation. Since all authorization derives from WorkspaceMember(workspace, member, role, is_active) rows, this is a full cross-tenant takeover. @allow_permission([ADMIN], level="WORKSPACE") authorizes against the URL slug but does not stop the write from moving the row elsewhere.

Fix

The endpoint's only legitimate mutation is role, so the writable payload is restricted to {"role": ...} in the view:

allowed_data = {}
if "role" in request.data:
    allowed_data["role"] = request.data.get("role")
serializer = WorkSpaceMemberSerializer(workspace_member, data=allowed_data, partial=True)

Why not fields=("id","member","role")? DynamicBaseSerializer.__init__ pops the fields= kwarg and immediately overwrites it with self.expand (serializers/base.py), so fields= never restricts writes or output. The view-level allowlist is the reliable fix and is the only write path through this serializer (list/retrieve are read-only), so no other consumer is affected. The self-role-update guard and guest role-cascade are preserved; is_active is only changed via destroy().

Tests

New tests/contract/app/test_workspace_member_mass_assignment_app.py — 4 cases (cross-workspace move blocked; is_active=false no-ops; legitimate role update works; self-update still 400). Fail-before verified on the CE docker test stack: 2 attack tests failed unpatched → 4 pass patched; 12/12 in a regression run with sibling member/authz suites.

Related finding (separate ticket — NOT in this PR)

The root cause exposed a latent bug: DynamicBaseSerializer's fields= kwarg is discarded (base.py: fields = self.expand), so every caller that passes fields=(...) expecting to limit output (e.g. list/retrieve here) actually returns all model columns. That's an app-wide over-disclosure surface needing its own scoped audit + FE-compat check — filed separately; deliberately not touched here (changing base.py would enforce output filtering app-wide and likely break the frontend).

Summary by CodeRabbit

  • Bug Fixes

    • Workspace member updates now accept only a role change; requests without a role leave the member unchanged.
    • Invalid role values return a client error without changing workspace or project roles.
    • Setting a member’s role to guest also updates their project roles.
  • Tests

    • Added coverage for ignored extra fields, role updates, self-role restrictions, and invalid role values.

…A-f739-39g5-jj49)

WorkSpaceMemberViewSet.partial_update passed request.data verbatim into
WorkSpaceMemberSerializer (fields="__all__"), making workspace, role, and
is_active mass-assignable. A workspace admin could PATCH a member row setting
workspace=<victim UUID> and role=20, relocating a controlled account into the
victim workspace as admin — full cross-tenant takeover.

The endpoint's only legitimate mutation is `role`, so restrict the writable
payload to {"role": ...}. Note: passing fields=("id","member","role") does NOT
work — DynamicBaseSerializer discards the fields= kwarg (base.py) — so the
allowlist is enforced in the view instead. Preserves the self-role-update guard
and the guest role-cascade. is_active is only changed via destroy(), not here.

Adds 4 contract tests; fail-before verified (2 attack tests failed unpatched → 4 pass).

Co-authored-by: Plane AI <noreply@plane.so>
Copilot AI lite review requested due to automatic review settings July 22, 2026 11:49
@coderabbitai

coderabbitai Bot commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 6da9fe67-beb9-430b-993d-73fcf0f4fc9f

📥 Commits

Reviewing files that changed from the base of the PR and between 63d1515 and 51ef630.

📒 Files selected for processing (2)
  • apps/api/plane/app/views/workspace/member.py
  • apps/api/plane/tests/contract/app/test_workspace_member_mass_assignment_app.py

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 6 remain after this review.


📝 Walkthrough

Walkthrough

Workspace member partial updates now accept only role, validate the role before side effects, and save role changes with related project-role updates atomically. Contract tests cover mass-assignment prevention, ignored activation changes, role updates, self-role rejection, and invalid roles.

Changes

Workspace member update security

Layer / File(s) Summary
Restrict and validate workspace member PATCH fields
apps/api/plane/app/views/workspace/member.py
partial_update accepts only role, validates it before cascading changes, and atomically saves the workspace member and project-role updates.
Validate update boundaries
apps/api/plane/tests/contract/app/test_workspace_member_mass_assignment_app.py
Contract tests cover cross-workspace reassignment, ignored activation changes, valid role updates, self-role rejection, and invalid roles without project-role changes.

Priority: ⬆️ High

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Merge Risk: ⚪ Minimal · up to 51ef6

The workspace-member PATCH change is mergeable after normal checks; no actionable risk remains identified.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 51ef6

The change blocks workspace reassignment and activation through this role-update endpoint and makes related role changes atomic. No new security weakness is established, though concurrent membership changes remain an area of uncertainty.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — Before the change, a workspace admin’s submitted fields could reach an all-fields member serializer after authorization against the URL workspace, creating a potential cross-workspace membership-write path. The new allowlist removes workspace from that path.

Trust Boundaries and Controls

  • observed — The endpoint retains workspace-admin permission enforcement and selects the target within the URL workspace. Attacker-supplied workspace and is_active fields no longer reach its serializer write path.

Resilience and Maintainability Implications

  • inferred — Concurrent project-member writes or member deactivation may require coordination with role downgrades: the member is read before the transaction, and no shared lock is visible. The relevant read-and-save pattern predates this PR, so this is not established as a new PR concern.

Hardening Proposals

  • proposed — Coordinate guest-role transitions with project-membership creation and member deactivation, and verify those interleavings with concurrent-transition coverage.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.85% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 13 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the security fix and its purpose: blocking workspace-member mass assignment.
Description check ✅ Passed The description provides a detailed security impact, root cause, fix, test coverage, and related issue context. It does not follow the repository template headings exactly and does not include the Typ…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@makeplane

makeplane Bot commented Jul 22, 2026

Copy link
Copy Markdown

Linked to Plane Work Item(s)

This comment was auto-generated by Plane

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/api/plane/tests/contract/app/test_workspace_member_mass_assignment_app.py`:
- Around line 99-107: Extend the mixed-payload assertions in the workspace
member update test to verify that the permitted role change is applied by
asserting puppet_member.role equals 20 after refresh_from_db(), while retaining
the existing assertions that the workspace remains unchanged and no victim
member is created.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 65e256f5-7f4d-4580-8509-f4bf08174ff0

📥 Commits

Reviewing files that changed from the base of the PR and between a8e53b6 and 46670e6.

📒 Files selected for processing (2)
  • apps/api/plane/app/views/workspace/member.py
  • apps/api/plane/tests/contract/app/test_workspace_member_mass_assignment_app.py

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses a critical security vulnerability (GHSA-f739-39g5-jj49 / WEB-8332) where WorkSpaceMemberViewSet.partial_update allowed workspace admins to mass-assign sensitive WorkspaceMember fields (e.g. workspace, is_active) and perform cross-tenant workspace takeover by relocating a controlled member row into another workspace.

Changes:

  • Restricts WorkSpaceMemberViewSet.partial_update to only accept role in the writable payload (view-level allowlist).
  • Adds contract regression tests covering the mass-assignment takeover vector and ensuring intended role updates still work.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
apps/api/plane/app/views/workspace/member.py Adds a view-level allowlist to prevent mass-assignment of WorkspaceMember fields via PATCH.
apps/api/plane/tests/contract/app/test_workspace_member_mass_assignment_app.py Adds contract regression tests to prevent cross-workspace moves and verify allowed role updates.

Comment thread apps/api/plane/app/views/workspace/member.py Outdated
…opilot #9460)

partial_update cascaded the guest project-role downgrade BEFORE the serializer
was validated/saved, using a manual int() cast that 500s on non-integer input.
Reorder: validate the serializer first, then cascade using
serializer.validated_data["role"] inside a transaction.atomic() with save(), so a
bad role returns 400 (not 500) and project roles can't be downgraded when the
member update doesn't persist. Adds a non-integer-role 400 test.

Co-authored-by: Plane AI <noreply@plane.so>
Copilot AI review requested due to automatic review settings July 22, 2026 12:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/api/plane/tests/contract/app/test_workspace_member_mass_assignment_app.py`:
- Around line 162-179: Extend test_non_integer_role_is_400_not_500 by creating a
related ProjectMember for target_member with a non-guest role before the invalid
PATCH, then refresh it and assert its role remains unchanged alongside
target_member.role. Use the existing project-member creation helpers and role
symbols visible in the test module.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1339de50-6ecc-41a5-857f-0a45edaba3bb

📥 Commits

Reviewing files that changed from the base of the PR and between 46670e6 and ea8a667.

📒 Files selected for processing (2)
  • apps/api/plane/app/views/workspace/member.py
  • apps/api/plane/tests/contract/app/test_workspace_member_mass_assignment_app.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/api/plane/app/views/workspace/member.py

…id update (CodeRabbit #9460)

Address two CodeRabbit review comments:

- Mixed-payload takeover test now also asserts the permitted field (role) in
  the same payload WAS applied (role == 20), proving the fix filters the
  payload rather than rejecting it wholesale.
- test_non_integer_role_is_400_not_500 now seeds a non-guest ProjectMember and
  asserts it stays role 15 after the invalid PATCH, so a regression that let
  the guest project-role cascade run on a failed update would be caught.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 23, 2026 09:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment thread apps/api/plane/app/views/workspace/member.py
…ld (Copilot #9460)

When a PATCH carries only forbidden keys (e.g. {"is_active": false}), allowed_data
is empty and the previous code still called serializer.save() — a no-op write that
bumped updated_at/updated_by and ran through the save path for nothing.

Early-return the current serialized member (200) when there are no allowed fields,
so a forbidden-only payload has no side effects. Strengthened the is_active test to
assert updated_at is unchanged (fail-before verified).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 23, 2026 09:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

@mguptahub mguptahub changed the title [WEB-8332] fix(security): block workspace-member mass-assignment (GHSA-f739-39g5-jj49) [WEB-8332] fix(security): block workspace-member mass-assignment Aug 7, 2026
Explanations kept unchanged; only the IDs are removed.

Co-authored-by: Plane AI <noreply@plane.so>
Copilot AI review requested due to automatic review settings August 7, 2026 10:34
@github-actions

github-actions Bot commented Aug 7, 2026 •

Copy link
Copy Markdown

React Doctor skipped this pull request — it changed no React files.

Reviewed by React Doctor for commit 51ef630.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants